跳到主要内容

MySQL 事务带来的问题

脏读的情况

脏读取(Dirty Reads):脏读指的就是 在不同的事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据。这是相当危险的很可能所有操作都被回滚

在理解脏读(Dirty Read)之前,需要理解脏数据的概念。“脏数据” 和之前所介绍的 “脏页” 完全是两种不同的概念

脏页指的是在缓冲池中已经被修改的页,但是还没有刷新到磁盘中,即数据库实例内存中的页和磁盘中的页的数据是不一致的,当然在刷新到磁盘之前,日志都已经被写人到了重做日志文件中。

而所谓脏数据是指事务对缓冲池中行记录的修改,并且还没有被提交(commit)。

对于脏页的读取,是非常正常的。脏页是因为数据库实例内存和磁盘的异步造成的,这并不影响数据的一致性(或者说两者最终会达到一致性,即当脏页都刷回到磁盘)。并且因为脏页的刷新是异步的,不影响数据库的可用性,因此可以带来性能的提高。

脏数据却截然不同,脏数据是指未提交的数据,如果读到了脏数据,即一个事务可以读到另外一个事务中未提交的数据,则显然违反了数据库的隔离性。

脏读指的就是在不同的事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据。

表 6-15 的例子显示了一个脏读的例子(设置成了读未提交隔离级别)。

可以看到会话 B 两次读取的数据不一样

不可重复读取

注意分清与上面脏读的区别!!不可重复读取(Non-repeatable Reads)是一个事务对同一行数据重复读取两次但是却得到了不同结果。例如在两次读取中途有另外一个事务对该行数据进行了修改并提交,而上面的脏读是直接读取到别的事务中未提交的数据

不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合,并做了一些 DML 操作。

因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。

这样就发生了在一个事务内两次读到的数据是不一样的情况,这种情况称为不可重复读。

不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但是其违反了数据库事务一致性的要求。可以通过下面一个例子来观察不可重复读的情况,如表 6-16 所示。(注意,这里隔离级别是读提交)

如上图,事务 A 第二次读取到了 事务 B 提交的数据

一般来说,不可重复读的问题是可以接受的,因为其读到的是已经提交的数据,本身并不会带来很大的问题。因此,很多数据库厂商(如Oracle、Microsoft SQL Server)将其数据库事务的默认隔离级别设置为 READ COMMITTED,在这种隔离级别下允许不可重复读的现象。

在 InnoDB 存储引擎中,通过使用 Next-Key Lock 算法来避免不可重复读的问题。在 MySQL 官方文档中将不可重复读的问题定义为Phantom Problem,即幻像问题。

在 Next-Key Lock 算法下,对于索引的扫描,不仅是锁住扫描到的索引,而且还锁住这些索引覆盖的范围(gap)。因此在这个范围内的插入都是不允许的。这样就避免了另外的事务在这个范围内插人数据导致的不可重复读的问题。

因此,InnoDB 存储引擎的默认事务隔离级别是 READ REPEATABLE,采用 Next-Key Lock 算法,避免了不可重复读的现象。

提示

这个 Next-Key Lock 就是用来解决幻读的问题的,也是读提交 READ COMMITTED,和可重复读 REPEATABLE READ 的核心区别,MVCC 只能保证 SELECT 的一致性,却无法保证 Delete 和 Update 的一致性,因为 Delete 和 Update 在它实际修改数据时,还是需要先去读取要修改的那个值的最新状况(所以无法避免 “当前读”),所以这时就无法通过多版本并发控制(MVCC)这种取巧的方式来保证一致性了,必须得要真实的锁住那一行的数据,因此引入了 Next-Key Lock

幻读的问题

幻读是什么?

幻读(Phantom Read)是数据库并发控制中的一种现象,指在一个事务中多次执行相同的查询,但得到的结果集不一致。具体来说,当一个事务在某个时间点执行一个范围查询(使用WHERE条件筛选),然后在该事务内的后续查询中,由于其他并发事务的插入、删除或更新操作,结果集中出现了新的行("幻影行"),导致了幻读现象。

幻读的发生是由于读操作的事务隔离级别和并发事务的写操作引起的。在可重复读隔离级别下,MySQL使用MVCC(多版本并发控制)来处理读操作的一致性。在执行范围查询时,MySQL会在事务开始时创建一个快照,并在事务期间使用该快照读取数据。但是,其他并发事务在事务期间对数据进行了插入、删除或更新操作,导致了幻读现象。

以下是一个幻读的示例:

假设有两个事务 T1 和 T2:

  • T1 执行以下查询:
    SELECT * FROM my_table WHERE age > 25;
  • T2 在此期间插入了一条新记录,且该记录满足 T1 查询的条件。

在这种情况下,当 T1 再次执行相同的查询时,会发现结果集中出现了新的行,这就是幻读现象。尽管 T1 在同一事务中多次执行了相同的查询,但由于并发事务的写操作,导致结果集不一致。

为了避免幻读问题,可以使用更高的隔离级别(如串行化隔离级别)或者使用锁来限制并发操作。

在某些情况下,可以使用范围锁(Range Lock)或间隙锁(Gap Lock)来防止其他事务在范围查询期间插入或删除数据,从而避免幻读的发生。

需要根据具体的业务需求和并发访问情况来选择合适的事务隔离级别和并发控制策略,以解决幻读问题。

可重复读隔离级别下的幻读

在可重复读(Repeatable Read)隔离级别下,幻读是一种可能发生的现象。幻读指的是在一个事务中多次执行相同的查询,但在后续的查询中,由于其他并发事务插入或删除了符合查询条件的行,导致结果集中出现了新的行("幻影行")。

在可重复读隔离级别下,MySQL使用MVCC(多版本并发控制)来保证读取数据的一致性。当事务开始时,MySQL会创建一个事务开始时的快照,并在整个事务期间使用该快照来读取数据。这意味着在同一个事务中,多次执行相同的查询应该得到一致的结果。

然而,幻读可以在可重复读隔离级别下发生,原因是其他并发事务在事务期间插入了新的行或者删除了符合查询条件的行。例如,一个事务在某个时间点执行以下查询:

SELECT * FROM my_table WHERE age > 25;

在该事务执行期间,另一个并发事务插入了一条满足条件的新记录。当该事务再次执行相同的查询时,会发现结果集中出现了新的行,这就是幻读。

幻读的发生是由于可重复读隔离级别下的快照读取机制,只能保证查询结果在事务开始时的一致性,但无法防止其他并发事务的插入或删除操作对结果集产生影响。

为了避免幻读,可以使用更高的隔离级别(如串行化隔离级别)或者使用锁来限制并发操作。在某些情况下,可以使用范围锁(Range Lock)或间隙锁(Gap Lock)来防止其他事务在范围查询期间插入或删除数据,从而避免幻读的发生。

需要根据具体的业务需求和并发访问情况来选择合适的事务隔离级别和并发控制策略,以解决幻读问题。

如何避免幻读

在MySQL中,可以使用范围锁(Range Lock)或间隙锁(Gap Lock)来防止其他事务在范围查询期间插入或删除数据,从而避免幻读的发生。以下是使用这些锁的一般步骤:

  1. 明确事务的隔离级别:确保事务隔离级别设置为可重复读(Repeatable Read)或更高级别。范围锁和间隙锁只在可重复读及以上隔离级别下才可用。

  2. 开启事务:在需要进行范围查询的事务中,通过START TRANSACTION语句开启一个事务。

  3. 执行范围查询:使用SELECT语句执行范围查询,使用FOR UPDATE语句加锁。例如:

    START TRANSACTION;
    SELECT * FROM my_table WHERE age > 25 FOR UPDATE;

    通过在查询语句后添加FOR UPDATE语句,可以获取范围锁并锁定查询结果集中的行。

  4. 范围锁生效:在事务执行期间,范围锁会锁定查询结果集中的行,防止其他事务在此范围内进行插入或删除操作。

  5. 提交或回滚事务:根据业务逻辑,决定是提交事务(COMMIT)还是回滚事务(ROLLBACK)。

范围锁和间隙锁会在查询期间锁定范围内的行和间隙,防止其他事务在该范围内进行插入或删除操作。这样可以确保在范围查询期间的数据一致性,避免幻读的发生。

需要注意的是,使用范围锁和间隙锁可能对并发性能产生一定的影响,因为锁定的范围越大,其他事务的并发操作可能会受到限制。因此,在使用范围锁和间隙锁时,需要评估业务需求和并发访问模式,权衡数据一致性和并发性能之间的平衡。

不可重复读取和幻读的区别

不可重复读(Non-repeatable Read)和幻读(Phantom Read)是数据库中的两个隔离级别问题,它们的区别如下:

  1. 不可重复读取(Non-repeatable Read):在一个事务中,当某个事务多次读取同一行数据时,会发现该行数据的值发生了变化。这是由于在并发环境下,其他事务可能已经修改了这一行的数据。不可重复读通常发生在读取过程中的间隙,其中某个事务在读取数据后,另一个事务修改了该数据,并且提交了事务。这导致前一个事务再次读取相同的数据时,得到的结果不一致。

  2. 幻读(Phantom Read):在一个事务中,当某个事务多次执行相同的查询时,会发现结果集中出现了新的行或少了原本存在的行。幻读通常发生在事务的执行期间,其中某个事务在读取数据后,另一个事务插入或删除了符合前一个事务查询条件的数据,并且提交了事务。这导致前一个事务再次执行相同的查询时,得到的结果集发生了变化,出现了幻行。

总结来说,不可重复读是指同一事务中多次读取同一行数据时发现数据已被修改,而幻读是指同一事务中多次执行相同的查询时发现结果集发生了变化,出现了新增或删除的行。

这两个问题都是由于并发事务对数据进行修改而导致的。为了解决这些问题,数据库提供了不同的隔离级别,如读未提交、读已提交、可重复读和串行化等级别,以控制事务之间的隔离程度,从而避免不一致的读取操作。

丢失更新

丢失更新是另一个锁导致的问题,简单来说其就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。

例如(假设 r = 1):

1、事务 T1 将行记录 r 更新为 v1,但是事务 T1 并未提交。(例如 + 1) 2、与此同时,事务 T2 将行记录 r 更新为 v2,事务 T2 未提交。(例如 + 2) 3、事务 T1 提交。(此时是 2) 4、事务 T2 提交。(此时是 3 但是实际上应该是 4)

但是,在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题。这是因为,即使是 READ UNCOMMITTED 的事务隔离级别,对于行的 DML 操作,需要对行或其他粗粒度级别的对象加锁。

因此在上述步骤 2 中,事务 T2 并不能对行记录 r 进行更新操作,其会被阻塞,直到事务 T1 提交。

虽然数据库能阻止丢失更新问题的产生,但是在生产应用中还有另一个 逻辑意义的丢失更新问题,而导致该问题的并不是因为数据库本身的问题。实际上,在所有多用户计算机系统环境下都有可能产生这个问题。

简单地说来,出现下面的情况时,就会发生丢失更新:

问题描述: MySQL 数据库丢失更新的定义:(其实在数据库角度都不会产生丢失更新的问题,问题的源头应用程序逻辑更新的问题)

1、事务1 查询一行数据放在本地缓存,并且显示给用户 user1

select balance from account where user= 'a';

2、事务2 查询同样的一行数据放在本地缓存,并且显示给用户 user2

select balance from account where user= 'a';

3、用户 user1 修改这条数据,并且更新提交数据库

update account set balance = balance - 100 where user= 'a';

4、用户user2修改显示的数据,并且更新提交数据库

update account set balance = balance -800 where user= 'a' ;

显然上面 user1 更新的数据丢失了,这也是更新覆盖,比如用户转账的操作。a 账户总共 1000,事务 1 和事务 2 查询账户都是1000,然后事务 1 账户扣减 100,提交。事务 2 扣减 800 提交。这时候账户余额为 200,事务 1 扣减的 100 会不翼而飞,这会导致严重的问题。

两种解决办法:

解决办法1:使用悲观锁(串行化)

-- 读取锁定 --> 
select balance from account where user= 'a' for update

-- 更新 -->
update account set balance = balance -100 where user= 'a'  

解决办法2: 使用乐观锁

-- 表增加字段 jpa_version int 版本号  -->  
select balance, version from account where user= 'a'

-- 使用版本号更新 -->
update account set balance = balance -100 where user= 'a' and jpa_version = ${version} 

死锁

死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。解决死锁问题最简单的方式是不要有等待,将任何的等待都转化为回滚,并且事务重新开始。

毫无疑问,这的确可以避免死锁问题的产生。然而在线上环境中,这可能导致并发性能的下降,甚至任何一个事务都不能进行。而这所带来的问题远比死锁问题更为严重,因为这很难被发现并且浪费资源。

解决死锁问题最简单的一种方法是 超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。

在 InnoDB 存储引擎中,参数 innodb_lock_wait_timeout 用来设置超时的时间。

超时机制虽然简单,但是其仅通过超时后对事务进行回滚的方式来处理,或者说其是 根据 FIFO(先进先出)的顺序选择回滚对象。但若超时的事务所占权重比较大,如事务操作更新了很多行,占用了较多的 undo log,这时采用 FIFO 的方式,就显得不合适了,因为回滚这个事务的时间相对另一个事务所占用的时间可能会很多。

因此,除了超时机制,当前数据库还都普遍采用 wait-for graph (等待图)的方式来进行死锁检测。较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB 存储引擎也采用的这种方式。wait-for graph 要求数据库保存以下两种信息:

  • 锁的信息链表
  • 事务等待链表

通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表存在死锁,因此资源间相互发生等待。在 wait-for graph 中,事务为图中的节点。而在图中,事务 T1 指向 T2 边的定义为:

  • 事务 T1 等待事务 T2 所占用的资源
  • 事务 T1 最终等待 T2 所占用的资源,也就是事务之间在等待相同的资源,而事务 T1 发生在事务 T2 的后面

下面来看一个例子,当前事务和锁的状态如图 6-5 所示。

在 Transaction Wait Lists 中可以看到共有 4 个事务 t1、t2、t3、t4,故在 wait-for graph 中应有 4 个节点。而事务 t2 对 row1 占用 x 锁,事务 t1 对 row2 占用 s 锁。事务 t1 需要等待事务 t2 中 row1 的资源,因此在 wait-for graph 中有条边从节点 t1 指向节点 t2。

事务 t2 需要等待事务 t1、t4 所占用的 row2 对象,故而存在节点 t2 到节点 t1、t4 的边。

同样,存在节点 t3 到节点 t1、t2、 t4 的边,因此最终的 wait-for graph 如图 6-6 所示。

通过图 6-6 可以发现存在回路 (t1, t2), 因此存在死锁。通过上述的介绍,可以发现 wait-for graph 是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说 InnoDB 存储引擎选择回滚 undo 量最小的事务。

wait-for graph 的死锁检测通常采用深度优先的算法实现,在 InnoDB1.2 版本之前,都是采用递归方式实现。而从 1.2 版本开始,对 wait-for graph 的死锁检测进行了优化,将递归用非递归的方式实现,从而进一步提高了 InnoDB 存储引擎的性能。